npm run dev
每個都要記得自己的啟動指令、連接埠、環境變數。今天用 Docker Compose 把它們寫進一個設定檔,docker compose up 一次啟動整個系統。
第 4 週:發佈與開源
讀完這篇後,你會完成:
frontend/Dockerfile:多階段建置,Node 打包、nginx 提供靜態檔案frontend/nginx.conf:把 /api/ 轉給後端容器docker-compose.yml:後端、前端、可選的 Ollama,具名 volume 保存資料SECRET_KEY 必填、.env.example 更新down / up 後資料仍在;切換到 Mistral 模式開發時(Day 14),前端由 Vite 開發伺服器提供,它會把 /api/ 代理到 localhost:8000。正式部署不用開發伺服器:
| 開發(Day 14) | 部署(今天) | |
|---|---|---|
| 前端 | Vite 開發伺服器,即時編譯 | npm run build 產生靜態檔案,nginx 提供 |
/api/ 代理 |
vite.config.js |
nginx.conf |
| 後端位址 | localhost:8000 |
Compose 網路中的服務名稱 api |
| Ollama 位址 | localhost:11434 |
host.docker.internal:11434 或 ollama:11434 |
前端程式碼完全不用改:Day 14 起,所有 API 呼叫都是相對路徑 /api/v1/...,交給誰代理都一樣。
瀏覽器 → http://localhost:8080
frontend(nginx:1.27-alpine)
├─ / → 打包好的 React 靜態檔案
└─ /api/ → http://api:8000
api(Day 25 的映像檔)
├─ volume srs-data → /app/data(SQLite、LLM 緩存)
└─ OLLAMA_URL
├─ 預設:host.docker.internal:11434(主機上的 Ollama)
└─ --profile ollama:ollama:11434(容器內的 Ollama)
.env 中的 SECRET_KEY 等設定:8080、API :8000
docker-compose.yml、frontend/Dockerfile、frontend/nginx.conf、frontend/.dockerignore(新增);.env.example(修改)專案結構變化:
srs-review-agent/
├── docker-compose.yml ← 【新增】
├── .env.example ← 修改:SECRET_KEY、COMPOSE_OLLAMA_URL
├── Dockerfile ← Day 25
└── frontend/
├── Dockerfile ← 【新增】多階段建置
├── nginx.conf ← 【新增】
└── .dockerignore ← 【新增】
建立 frontend/Dockerfile:
# Day 26:前端映像檔(多階段建置)
# 第 1 階段:用 Node 打包
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# 網路不穩時重試(實測曾遇到 ECONNRESET)
RUN npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000
COPY . .
# 設為 1 時打包 Day 14 的免登入版(VITE_ 開頭的變數在打包時寫入)
ARG VITE_PUBLIC_MODE=
ENV VITE_PUBLIC_MODE=$VITE_PUBLIC_MODE
RUN npm run build
# 第 2 階段:只把打包結果交給 nginx
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
node_modules、原始碼都只存在於第 1 階段,最後的映像檔只有 nginx 與打包好的檔案npm ci 而不是 npm install:完全依照 package-lock.json 安裝,每次建置的版本都一樣--fetch-retries:第一次建置時,npm ci 在第 20 秒因為網路連線被重設(ECONNRESET)而失敗,整個建置跟著中止。加上重試後順利完成。建置環境的網路不穩是常態,值得預先處理VITE_PUBLIC_MODE 是建置參數:Day 16 的 index.jsx 用 import.meta.env.VITE_PUBLIC_MODE 選擇根元件。Vite 的環境變數在打包時就寫死在 JavaScript 裡,容器執行時再設定是沒有用的,所以必須用 ARG 在建置時傳入frontend/.dockerignore 排除 node_modules/ 與 dist/:容器會自己 npm ci,不需要主機上那一份。
建立 frontend/nginx.conf:
server {
listen 80;
root /usr/share/nginx/html;
# 與 vite.config.js 相同:結尾斜線讓 /apiClient.js 這類檔案不會被轉走
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 背景任務模式下 POST 會立即回應;這裡放寬是為了慢速的 Ollama 請求
proxy_read_timeout 300s;
}
location / {
try_files $uri /index.html;
}
}
location /api/ 的結尾斜線,和 Day 14 在 vite.config.js 踩過的坑是同一個道理:只轉發真正的 API 路徑。
http://api:8000 的 api 是 Compose 裡的服務名稱。Compose 會建立一個內部網路,每個服務都能用名稱找到彼此。
在專案根目錄建立 docker-compose.yml,先看後端:
services:
api:
build: .
image: srs-review-agent:latest
environment:
SECRET_KEY: ${SECRET_KEY:?請在 .env 設定 SECRET_KEY}
SRS_USE_OLLAMA: ${SRS_USE_OLLAMA:-1}
# 不沿用 .env 的 OLLAMA_URL:那是本機開發用的 localhost,在容器裡會指向容器自己
OLLAMA_URL: ${COMPOSE_OLLAMA_URL:-http://host.docker.internal:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-mistral}
volumes:
- srs-data:/app/data # 資料庫與 LLM 緩存
extra_hosts:
- "host.docker.internal:host-gateway" # Linux 也能連到主機上的 Ollama
ports:
- "8000:8000"
restart: unless-stopped
三個值得說明的地方:
${SECRET_KEY:?訊息}:沒有設定就拒絕啟動
Day 15 提過,SECRET_KEY 有一個公開的預設值,正式環境忘了設定時,任何人都能偽造 token。:? 是 Compose 的必填語法,沒有設定時直接報錯:
Error while interpolating services.api.environment.SECRET_KEY: required variable SECRET_KEY is missing a value: 請在 .env 設定 SECRET_KEY
這個檢查對所有 docker compose 指令都會生效,連 docker compose ps 也一樣,所以 SECRET_KEY 應該寫在 .env 裡,而不是每次手動帶入。
COMPOSE_OLLAMA_URL:不沿用 OLLAMA_URL
Compose 會自動讀取專案根目錄的 .env。但這個專案的 .env(Day 2 起)本來就有 OLLAMA_URL=http://localhost:11434,那是給本機開發用的。如果 Compose 直接用 ${OLLAMA_URL},容器就會去連容器自己的 11434 埠。所以 Compose 改用另一個變數名稱,預設指向主機。
用 docker compose config 確認:即使 .env 裡寫的是 localhost,容器拿到的仍是:
OLLAMA_URL: http://host.docker.internal:11434
extra_hosts: host-gateway
host.docker.internal 是 Docker Desktop 內建的名稱,Linux 的 Docker Engine 沒有。這一行讓 Linux 也有同樣的名稱,Day 25 提到的平台差異在這裡處理掉。
前端與 Ollama:
frontend:
build:
context: ./frontend
args:
VITE_PUBLIC_MODE: ${VITE_PUBLIC_MODE:-}
image: srs-review-agent-frontend:latest
depends_on:
api:
condition: service_healthy # 等 api 的 HEALTHCHECK 通過再啟動
ports:
- "8080:80"
restart: unless-stopped
# 可選:容器內的 Ollama(第一次需下載模型,約 4.4GB)
ollama:
image: ollama/ollama:latest
profiles: ["ollama"]
volumes:
- ollama-models:/root/.ollama
ports:
- "11434:11434"
restart: unless-stopped
volumes:
srs-data:
ollama-models:
condition: service_healthy:等 Day 25 的 HEALTHCHECK 通過才啟動前端,避免使用者打開頁面時後端還沒準備好profile 裡:預設不啟動,直接用主機上已經下載好模型的 Ollama。要完全容器化時加上 --profile ollama,並把 COMPOSE_OLLAMA_URL 設成 http://ollama:11434
srs-data 存資料庫與緩存,ollama-models 存模型檔。docker compose down 不會刪除它們更新 .env.example,把需要的設定寫清楚:
# 本機開發:Ollama 位址與模型(Day 11)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=mistral
# JWT 簽章金鑰(Day 15)。正式環境務必換成隨機字串,例如:
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# docker compose 會要求這個值(Day 26)
SECRET_KEY=change-me
# docker compose 專用:容器內連 Ollama 的位址(Day 26)
# 預設 http://host.docker.internal:11434(主機上的 Ollama)
# 使用 --profile ollama 時改為 http://ollama:11434
# COMPOSE_OLLAMA_URL=http://host.docker.internal:11434
cp .env.example .env # 並把 SECRET_KEY 改成隨機字串
docker compose up -d --build
第一次建置約 5 分鐘,大部分時間花在前端的 npm ci 與下載 node:20-alpine、nginx:1.27-alpine 基礎映像檔。完成後:
Container srs-review-agent-api-1 Healthy
Container srs-review-agent-frontend-1 Started
前端映像檔只有 76.4MB,是多階段建置的成果;後端仍是 Day 25 的 414MB。
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/ # 200
curl http://localhost:8080/api/v1/health # 經 nginx 轉給 api
{"status": "healthy", "version": "1.0.0", "components": {"detector": "ready", "llm": "mock", ...}}
這次驗證先用 SRS_USE_OLLAMA=0 啟動(規則模式),因為同一時間主機的 Ollama 正在跑 Day 21 的 A/B 實驗。
用 Playwright 驅動 headless Chromium 打開 http://localhost:8080,走一遍 Day 16 的流程:
200 POST /api/v1/auth/register
200 POST /api/v1/auth/login
200 GET /api/v1/auth/jobs
200 POST /api/v1/detect
200 GET /api/v1/auth/jobs
200 GET /api/v1/auth/jobs/<id>
結果: 發現 1 個衝突:
200 GET /api/v1/auth/me ← 重新整理頁面
200 GET /api/v1/auth/jobs
所有請求都經過 nginx 轉發,重新整理後仍維持登入(Day 17 的 token 存在 localStorage),console 沒有錯誤。
先用 API 註冊一個帳號、提交一個檢測任務,然後:
docker compose down # 停止並移除容器(不加 -v,volume 保留)
docker compose up -d
| 檢查 | 結果 | 原因 |
|---|---|---|
用重啟前的 token 呼叫 /api/v1/auth/me |
401 | Day 17 的會話存在記憶體,容器重啟就清空 |
| 用同一組帳密重新登入 | 200 | users 表在 srs-data volume 裡 |
| 查詢任務列表 | 1 個任務,completed |
jobs 表在 srs-data volume 裡 |
帳號與任務都保留了,只有登入狀態需要重來。這正是 Day 17 寫下的限制「伺服器重啟後所有人都要重新登入」;在容器環境,每次更新映像檔都會發生一次。
Day 21 的 A/B 實驗結束後,改用預設的 SRS_USE_OLLAMA=1 重新啟動後端:
docker compose up -d
curl http://localhost:8080/api/v1/health
{"status": "healthy", ..., "components": {"detector": "ready", "llm": "ollama:mistral", ...}}
提交 Day 13 那份電商需求中的 3 條:
curl -X POST http://localhost:8080/api/v1/conflicts/detect -H "Content-Type: application/json" \
-d '{"constraints": [
{"id": "REQ-1", "text": "支持明文顯示訂單細節"},
{"id": "REQ-2", "text": "不需要加密用戶的個人信息"},
{"id": "REQ-3", "text": "所有支付數據必須加密傳輸"}]}'
15.4 秒後完成,5 次 LLM 呼叫(批量驗證 1、補充 1、自我矛盾 3),回報 3 個衝突:
| 需求 | 類型 | 來源 |
|---|---|---|
| REQ-1 ↔ REQ-3 | 安全性衝突 | 規則,LLM 驗證保留 |
| REQ-3 ↔ REQ-2 | 邏輯矛盾 | LLM 補充 |
| REQ-2(自身) | 自相矛盾 | 第 4 層 |
規則層的誤報「明文顯示訂單 vs 不需要加密個人信息」被批量驗證擋下,和 Day 13 的結果一致。第 4 層把「不需要加密用戶的個人信息」判為自相矛盾,理由與 Day 21 A/B 中的那個誤報一字不差:「可能存在隱私漏洞」。容器化沒有改變任何判斷。
api 擴展成多個副本。要水平擴展,需要先把會話移到 RedisDATABASE_URL 指向它(Day 15)--profile ollama 需要在容器內下載 4.4GB 的模型,本文只驗證了「連主機上的 Ollama」這條路徑。在 macOS 上,容器內的 Ollama 也無法使用 Apple Silicon 的 GPU,會比主機上慢很多SECRET_KEY 寫在 .env:比寫在 Compose 檔或映像檔安全,但仍是明文檔案。正式環境可以改用 Docker secrets 或雲端平台的秘密管理git add docker-compose.yml .env.example frontend/Dockerfile frontend/nginx.conf frontend/.dockerignore
git commit -m "Day 26: Docker Compose(api、nginx 前端、可選 Ollama、具名 volume)"
.env 本身不要提交,裡面有 SECRET_KEY。
系統已經能一行指令部署了。但 Day 24 在一份 58 條需求的規格書上,看到補充層回報了 17 個全部錯誤的衝突,補充 prompt 也已經用掉 2,623 個 token、接近 Mistral 的 4096 上限。明天 Day 27 我們做大規模性能基準:用程式產生不同大小、衝突數量已知的規格書,量測每一層的耗時、LLM 呼叫次數與準確率如何隨規模變化,找出這套系統能處理多大的規格書。